feat(mcp): per-entity PO modify card with field-level diff overlay — #722#755
Conversation
There was a problem hiding this comment.
Pull request overview
This PR implements the first step of the #721 modify-card redesign by migrating purchase-order modification UIs to a per-entity Prefab card that overlays field-level before → after diffs, and updates modification dispatch to route PO responses to the new builder while leaving other entities on the legacy path.
Changes:
- Added shared field-diff projection utilities (
FieldChangeView,_index_changes_by_field,_render_field_diff_line) for modify-card rendering. - Extracted a shared PO entity-view renderer (
_render_po_entity_view) and introducedbuild_po_modify_uifor PO modify/delete/correct cards. - Updated
_modification.to_tool_resultto dispatchentity_type == "purchase_order"to the new PO modify UI; added targeted tests for the new helpers and PO modify card.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
katana_mcp_server/src/katana_mcp/tools/prefab_ui.py |
Adds shared field-diff helpers, extracts shared PO entity view, and introduces build_po_modify_ui using direct-apply scaffolding. |
katana_mcp_server/src/katana_mcp/tools/_modification.py |
Updates modification ToolResult UI dispatch to route purchase orders to build_po_modify_ui, with other entities falling back to legacy builders. |
katana_mcp_server/tests/test_prefab_ui.py |
Adds unit tests for _index_changes_by_field and smoke/behavior tests for the new PO modify UI + shared entity-view contract. |
Comments suppressed due to low confidence (4)
katana_mcp_server/src/katana_mcp/tools/prefab_ui.py:2303
build_po_modify_uireuses_render_preview_header, but that helper hard-codes create-card wording (title suffix "Created" and state badge "CREATED"). In applied modify/delete/correct flows this will render incorrect UI copy (e.g., "Modify Purchase Order Created"). Consider adding a modify-aware header helper or making_render_preview_headeraccept the applied-state label/title suffix (e.g., APPLIED/DELETED/CORRECTED).
_render_preview_header(
title_prefix=f"{verb_label} Purchase Order",
entity="purchase_order",
order_number=str(entity.get("order_number") or entity_id or "N/A"),
status=entity.get("status"),
extra_badges=extra_badges,
)
katana_mcp_server/src/katana_mcp/tools/prefab_ui.py:2330
build_po_modify_uicalls_render_preview_footer, whose applied-state message is always "{title_prefix} created.". Withtitle_prefix=f"Purchase Order {verb_label}"this produces incorrect text like "Purchase Order Delete created." on applied cards. This likely needs a modify-specific footer message (applied/delete/correct) or a parameter to_render_preview_footerto customize the applied-state copy for non-create operations.
_render_preview_footer(
title_prefix=f"Purchase Order {verb_label}",
block_warnings=block_warnings,
confirm_label=confirm_label,
apply_action=apply_action,
cancel_action=cancel_action,
# No next-action buttons on modify cards by default — the
# user already had the PO they wanted to change; surfacing
# "Receive Items" here would be noise. Delete operations
# also have nothing useful to suggest (the PO is gone).
next_action_buttons=(),
)
katana_mcp_server/src/katana_mcp/tools/prefab_ui.py:2271
entity = {**prior_state, **{k: v for k, v in response.items() if v is not None}}assumes theresponsedict contains the post-change PO fields. Butto_tool_result()passesModificationResponse.model_dump(), andModificationResponsedoes not include PO fields likestatus,supplier_name,order_number, etc. As a result, applied cards will continue to show the prior status/order/supplier in the header/body unless you explicitly compute a post-state by applyingchanges_by_field[*].afterontoprior_state(or otherwise supply the post-state).
verb_label = _verb_label(confirm_tool)
# Compose the entity view's source-of-truth dict by overlaying the
# response on top of prior_state. Preview: prior_state is the full
# pre-change snapshot; response-level scalars (warnings, katana_url)
# win. Applied: the response carries the post-change scalars too —
# the same overlay yields the post-change entity, modulo nested
# collections which the dispatcher already updated.
entity = {**prior_state, **{k: v for k, v in response.items() if v is not None}}
# ``entity_id`` from the response is the PO's identity; surface it
# under the same key the create-card pattern uses so the header
# Badge picks it up regardless of which payload populated it.
if entity_id is not None:
entity.setdefault("id", entity_id)
katana_mcp_server/src/katana_mcp/tools/prefab_ui.py:2251
build_po_modify_uidepends onresponse["prior_state"]for rendering the PO entity view (supplier/location/metrics). Howeverrun_modify_plan()currently does not populateprior_stateon the preview path (it only sets it on apply), so realmodify_purchase_orderpreviews will render without those entity fields. Either include a serializedprior_statein previewModificationResponsewhenexistingis available, or adjust this builder to explicitly handle the missing-prior_state case (e.g., render only diff lines + a warning).
actions = response.get("actions") or []
is_preview = bool(response.get("is_preview", True))
entity_id = response.get("entity_id")
prior_state = response.get("prior_state") or {}
# Note: katana_url is read from RESULT via the Prefab template
ff46bb8 to
224162e
Compare
224162e to
2530587
Compare
2530587 to
fe4fb47
Compare
12f7685 to
199d0a7
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 4 out of 5 changed files in this pull request and generated 2 comments.
Comments suppressed due to low confidence (1)
katana_mcp_server/src/katana_mcp/tools/prefab_ui.py:2316
_summarize_apply_outcomereturns(state_label, badge_variant)but current call sites only use[0], and the computedbadge_variantis not applied anywhere. Either drop the unused return value (return just the label) or wire the variant into_render_preview_header(e.g., via anapplied_state_variantparameter) so FAILED/PARTIAL FAILURE can render as destructive as intended.
"""Bucket a modify response's action outcomes for the header Badge.
Returns ``(state_label, badge_variant)``. Variants align with the
create-card Tier 1 vocabulary (``default`` / ``secondary`` /
``destructive`` / ``outline``).
199d0a7 to
db35f5f
Compare
db35f5f to
2eb41bb
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 4 out of 5 changed files in this pull request and generated 1 comment.
Comments suppressed due to low confidence (2)
katana_mcp_server/src/katana_mcp/tools/prefab_ui.py:2481
- In the direct-apply (preview → Confirm) flow, the iframe keeps the original Prefab tree and only updates state via
SetState("result", RESULT)+SetState("applied", True). However, this card’s body is rendered from the initialresponse/entity/changes_by_field, so after apply it will still show the previewresponse["message"]and preview-time diffs, and it cannot surface apply-time failures (✗ + error) becausechanges_by_fieldwas computed from preview actions (succeeded=None). To make applied/partial-failure UI accurate in the in-place morph path, render applied-state content fromstate.result(or populate dedicated state fields inextra_on_success) and switch the body/message/diff source whenappliedis true.
with CardContent(), Column(gap=3):
if response.get("message"):
Muted(content=response["message"])
block_warnings = _render_po_entity_view(entity, changes=changes_by_field)
# Confirm label scales with the number of planned actions —
katana_mcp_server/src/katana_mcp/tools/prefab_ui.py:2466
- On the standalone-applied path,
_summarize_apply_outcome(actions)can set the state badge to FAILED/PARTIAL FAILURE, but the title suffix and footer verb remain hardcoded to "Applied"/"applied" for modify/correct. That makes the card read e.g. "Modify Purchase Order Applied" and "… applied." even when the outcome is FAILED, which is misleading. Consider deriving the applied title/footer copy from the same outcome summary (at least for full failure) so the user-visible text stays consistent with the header badge.
else:
applied_title_suffix = "Applied"
applied_state_label = "APPLIED"
applied_verb = "applied"
applied_state_variant = "default"
# On the standalone-applied path (is_preview=False), let the
# actual outcome drive both the state label AND the badge
# variant — so a fully-failed apply reads "FAILED" with the
# destructive (red) variant, not "APPLIED" with the success
# (green) variant. Partial failures also surface in the
# destructive variant. The in-place morph path can't know the
# outcome at build time, so it keeps APPLIED/DELETED + default
# — failed actions there surface via the per-field ✗ glyphs.
if not is_preview:
outcome_label, outcome_variant = _summarize_apply_outcome(actions)
if outcome_label != "APPLIED":
applied_state_label = outcome_label
applied_state_variant = outcome_variant
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 4 out of 5 changed files in this pull request and generated 3 comments.
Comments suppressed due to low confidence (2)
katana_mcp_server/src/katana_mcp/tools/prefab_ui.py:2165
_render_po_entity_viewreads PO fields using the create-card response shape (total_cost,item_count, etc.). ButModificationResponse.prior_stateis serialized from the Katana API model (RegularPurchaseOrder.to_dict()), which uses different keys (e.g.total,purchase_order_rows,additional_info,order_no, nestedsupplier). As a result, real modify/delete/correct cards will likely omit the Total/Line Items metrics (and other context) even whenprior_stateis present. Consider normalizing the PO snapshot into the create-card field names (or add fallbacks liketotal_cost or totaland deriveitem_countfrompurchase_order_rows) before rendering.
changes = changes or {}
total_cost = entity.get("total_cost")
currency = entity.get("currency")
item_count = entity.get("item_count")
katana_mcp_server/src/katana_mcp/tools/prefab_ui.py:2472
build_po_modify_uirenders the header order-number and status fromentity.get("order_number")/entity.get("status"), but the entity dict is primarily composed fromprior_stateand does not applyactions[*].changesto compute the post-change values. This contradicts the inline comment in_render_po_entity_viewthat status changes “already surface in the Tier 1 header Badge”, and can cause the header badge(s) to show the pre-change status/number while the body diff shows the new value. Consider deriving header fields fromchanges_by_fieldwhen present (e.g.status_change.after,order_no/order_numberchange.after) and falling back to the snapshot only when no diff is available.
_render_preview_header(
title_prefix=f"{verb_label} Purchase Order",
entity="purchase_order",
order_number=str(entity.get("order_number") or entity_id or "N/A"),
status=entity.get("status"),
2eb41bb to
5b57bd5
Compare
5b57bd5 to
fc9867d
Compare
fc9867d to
a6368b9
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 5 out of 6 changed files in this pull request and generated 3 comments.
Comments suppressed due to low confidence (3)
katana_mcp_server/src/katana_mcp/tools/prefab_ui.py:1417
- The module comment says failed fields render a leading ✗ plus an inline error line, but the implementation aggregates error messages into the bottom Alert via _render_failed_changes_block (and explicitly avoids inline error text). Please update this comment to match the actual rendering behavior to avoid misleading future maintainers.
This issue also appears in the following locations of the same file:
- line 1541
- line 2516
]
def _format_qty_change(qty: float) -> str:
"""Format a quantity change with leading sign (e.g., ``+1.0``, ``-3.5``)."""
return f"{qty:+.1f}"
katana_mcp_server/src/katana_mcp/tools/prefab_ui.py:1546
- _render_field_diff_line docstring describes rendering an inline Muted error line when change.failed is True, but the function intentionally does not render inline error text (errors are consolidated in _render_failed_changes_block). Update the docstring to reflect the actual behavior.
parent action's ``succeeded`` / ``error`` so failed actions surface
per-field on every field they were going to write. A field appearing
in two actions (rare) takes the last write — the iteration order
matches the action plan's execution order, so the last write is the
one that ran (or would have run) most recently.
"""
katana_mcp_server/src/katana_mcp/tools/prefab_ui.py:2521
- build_po_modify_ui docstring says failed fields show a leading ✗ glyph + inline error, but errors are actually consolidated into the bottom Alert block. Updating this docstring would keep the documented contract consistent with the renderer.
with CardContent(), Column(gap=3):
block_warnings = _render_po_entity_view(response)
_render_preview_footer(
title_prefix="Purchase Order",
block_warnings=block_warnings,
confirm_label="Confirm & Create Purchase Order",
a6368b9 to
d4b7502
Compare
d4b7502 to
8d4f7b7
Compare
8d4f7b7 to
80155b1
Compare
80155b1 to
ebf33b8
Compare
…722 Foundation + first migration for the #721 modify-card umbrella. Replaces the today's ``ActionResult``-shaped modify card (which showed "Update Header / 2 field(s) changed / Operation #1, Target #2641831" — internal model abstractions the user can't action on) with a per-entity entry that mirrors the create-card layout and decorates the changing fields with ``before → after``. What lands: - ``FieldChangeView`` + ``_index_changes_by_field`` + ``_render_field_diff_line`` — renderer-facing field-diff projection, shared by every future modify card. Flattens ``ActionResult.changes`` across all actions into a single field-name keyed map so the entity view's render code can do simple ``changes.get("expected_arrival_date")`` lookups. - ``_render_po_entity_view(entity, *, changes=None)`` — the PO entity-view body, extracted from the old inlined ``build_po_create_ui`` Tier 2/3 content. Called by BOTH the create card (``changes=None``) and the new modify card. Single source of truth for "what fields show in a PO card"; the create-modify pair can't drift over time. - ``build_po_modify_ui`` — the new entrypoint. Handles ``modify_purchase_order`` / ``delete_purchase_order`` / ``correct_purchase_order`` (verb from ``confirm_tool``). Renders the entity view with the diff overlay; the leading ``✗`` glyph + inline Muted error line appears only on failed actions (the agreed hybrid status approach — card-level header Badge carries the all/most-case status, per-field decoration only when it carries information). - Dispatch in ``_modification.to_tool_result`` — switches on ``response.entity_type``. PO routes to the new entrypoint; other entities fall through to the legacy ``build_modification_preview_ui`` / ``build_modification_result_ui`` pair until their child PRs (#723 SO, #724 MO, #725 stock-transfer, #726 item) ship. The legacy builders delete once every entity migrates. Tests: - ``TestFieldDiffIndex`` — pins ``_index_changes_by_field`` projection (multi-action flattening, added/unchanged kinds, failed-action error propagation). - ``TestBuildPOModifyUI`` — smoke + diff-decoration + supplier composite diff + failed-action ``✗`` glyph + delete verb + partial-failure header badge + ``state.result`` seeding on applied path. - ``TestPOEntityViewSharedBetweenCreateAndModify`` — pins the dual-call contract so a future refactor of ``_render_po_entity_view`` can't silently drift the create card away from modify. What this PR does NOT touch (deferred to follow-ups): - Line-item / additional-cost adds/removes (the ``add_row`` / ``delete_row`` / ``add_additional_cost`` operations) — the entity view doesn't yet render those rows. The current PO create card doesn't render them either, so this is consistent with #728's create-card scope, not a regression. Adding the nested-rows section is its own follow-up (probably the next PO modify PR or rolled into the SO migration where the entity-view component for line-items first matters). - Other entities (SO, MO, stock_transfer, item) — child PRs #723-#726. Closes #722 Refs #721 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ebf33b8 to
8516ffb
Compare
Summary
First migration in the #721 modify-card umbrella. Replaces the today's PO modify card (which showed internal-model abstractions like "Update Header / 2 field(s) changed / Operation #1, Target #2641831") with a per-entity entry that mirrors the create-card layout and decorates the changing fields with `before → after`.
What lands
`FieldChangeView` + `_index_changes_by_field` + `_render_field_diff_line` — renderer-facing field-diff projection, shared by every future modify card. Flattens `ActionResult.changes` across all actions into a single field-name keyed map so the entity view's render code does simple `changes.get("expected_arrival_date")` lookups.
*`_render_po_entity_view(entity, , changes=None)` — the PO entity-view body, extracted from the old inlined `build_po_create_ui` Tier 2/3 content. Called by BOTH the create card (`changes=None`) and the new modify card. Single source of truth for "what fields show in a PO card"; the create-modify pair can't drift over time.
`build_po_modify_ui` — the new entrypoint. Handles `modify_purchase_order` / `delete_purchase_order` / `correct_purchase_order` (verb from `confirm_tool`). Renders the entity view with diff overlay; leading `✗` glyph + inline Muted error line appears only on failed actions (the agreed hybrid status approach — card-level header Badge carries the all/most-case status, per-field decoration only when it carries information).
Dispatch in `_modification.to_tool_result` — switches on `response.entity_type`. PO routes to the new entrypoint; other entities fall through to the legacy `build_modification_preview_ui` / `build_modification_result_ui` pair until their child PRs (design(mcp): build_so_modify_ui — diff-decorated SO modify card (#721 child) #723 SO, design(mcp): build_mo_modify_ui — diff-decorated MO modify card (#721 child) #724 MO, design(mcp): build_stock_transfer_modify_ui — diff-decorated stock-transfer modify card (#721 child) #725 stock-transfer, design(mcp): build_item_modify_ui — diff-decorated item modify card (#721 child) #726 item) ship.
Hybrid status design (agreed in #721 design session)
The card-level header Badge already tells you APPLIED / PARTIAL FAILURE / FAILED at a glance. Per-field decoration only carries information in the failure case — so:
This trims real estate dramatically — the all-success case is just diffs, no status furniture. The user only "pays" the glyph + error-line cost when there's actually something to look at.
What this PR does NOT touch (deferred to follow-ups)
Tests
Test plan
Closes #722
Refs #721
🤖 Generated with Claude Code